Search (availability) ยท Booking (consistency, no double-sell) ยท 10M users on 1 event ยท read-heavy 100:1 ยท <500ms search
The split personality (availability for search, consistency for booking) is the whole interview. Say it up front.
Event { id, venueId, performerId,
name, description, date }
Venue { id, location, seatMap }
Performer{ id, name }
Ticket { id, eventId, seat, price,
status: available|reserved|booked,
userId }
Booking { id, userId, ticketIds[],
status: pending|completed|failed }
Ticket state lives in the DB. Redis holds a short-lived lock, never the truth.
GET /events?search_term=&location=&date=
โ [Event] (search by any combo)
GET /events/{id}
โ Event + available tickets
POST /bookings
Idempotency-Key: <uuid>
{ eventId, ticketIds: string[] }
โ { bookingId } (reserves, TTL)
POST /bookings/{id}/payment
โ confirmation (commits sale)
Two-step: reserve first (lock seats), pay second (finalize). Client generates the idempotency key.
Two paths. Read path: Client โ CDN โ Gateway โ Search (Elasticsearch) or Event svc (Redis โ replicas). Write path: Gateway โ (waiting queue if hot) โ Booking svc โ Redis lock โ leader Postgres โ Stripe.
GET /events/{id} misses. Leader only takes writes.eventId, so replays/duplicates are safe. Reconcile ES against Postgres periodically to heal CDC gaps.Postgres --WAL--> CDC (Debezium)
--> Kafka --> ES upsert(eventId)
ES has internal caches, but none is an app-layer read-through store:
city=SF) per segment.Keep Redis in front for hot event pages / exact-repeat queries: sub-ms, you control the TTL. Different job than ES.
POST /bookings {eventId, ticketIds}
1. For each ticketId, SET NX in Redis:
SET lock:{ticketId} {userId}
NX EX 600 (10-min TTL)
Multiple tickets โ acquire in sorted
order (avoid deadlock), atomic.
2. Any SET NX fails โ seat taken โ
release the ones you got โ 409.
3. All locks held โ Booking row
(status=pending), return bookingId.
POST /bookings/{id}/payment
4. Stripe charge succeeds โ
TXN: tickets.status=booked,
booking.status=completed,
delete Redis locks.
The final commit re-checks ticket.status='available' inside the transaction (or uses SELECT ... FOR UPDATE / optimistic version column). If Redis ever fails open, the DB still refuses a double-sell. Redis is an optimization for the common case, not the correctness guarantee.
The Redis SET NX + TTL here and the DB SKIP LOCKED in ยง11 both hold a reservation. Don't run both โ two stores drift (Redis key expires but the DB row stays reserved forever). The choice follows the seat-selection model:
SET NX per ticketId, TTL auto-releases. DB row stays available until payment; Redis is the only reservation store.status='reserved' + reserved_until, reclaimed lazily + by a sweeper. SKIP LOCKED removes the stale-read window that Redis was there to paper over.sales_mode ('pick_seat' | 'best_available') โ a config field set by the promoter/ops before the onsale, not a live popularity guess and not a separate hand-kept map.event:{id}), pushed on change. No per-request DB lookup just to route.best_available for general sections and pick_seat for accessible/premium seats. Cleanly handles the ยง11 accessible-seating exception.Client retries POST /bookings (timeout, double-tap, flaky network). Without protection you reserve/charge twice.
Idempotency-Key per logical booking attempt.idem:{key} โ {status, bookingId} in Redis on first receipt (SET NX).ticketIds the natural idempotency scope: re-reserving seats you already hold is a no-op, not a conflict.One line to say: "Every mutating call is safe to retry โ client key on POST /bookings, same key forwarded to Stripe for the charge."
GET /events/{id}, availability counts, CDC to Elasticsearch.eventId so one megaevent's booking load lands on its own partition and doesn't starve others.eventId keeps every ticket for one event co-located โ single-partition transactions, no cross-shard 2PC.10M users refreshing one event will crush the booking service and Redis locks. You can't lock-check 10M/s. Shed load before it reaches the booking path.
On event open, gate entry:
queue:{eventId} โ sorted set of
sessionIds by ts
admitted:{eventId}โ set of allowed
Admit a steady trickle (e.g. N/sec)
from the front of the queue into the
real booking flow. Everyone else waits
and sees position + ETA.
POST /bookings; token expiry returns their slot.Only turn the queue on for flagged high-demand events โ normal events skip it entirely.
The go-to for popular onsales. User picks a section and a quantity, not specific seats. Server atomically selects and reserves the best available block in one statement โ no human between the read and the write, and no Redis. Reservation state lives in the DB.
BEGIN;
SELECT id FROM tickets
WHERE eventId=? AND section=?
AND (status='available'
OR (status='reserved'
AND reserved_until < now()))
ORDER BY seat_quality
LIMIT ? -- qty
FOR UPDATE SKIP LOCKED; -- key
UPDATE tickets
SET status='reserved', userId=?,
reserved_until = now()+'10 min'
WHERE id IN (...);
COMMIT;
SKIP LOCKED makes concurrent requests walk past each other's locked rows instead of colliding โ roughly one success per available seat, not thousands fighting over the same few hundred attractive ones.The txn is short โ pick, flip to reserved, commit. It does not hold the row lock for the 10-min checkout (that would pin a connection). The reserved status is what keeps other pickers off. Abandoned carts are reclaimed lazily (the reserved_until < now() predicate treats them as free) plus a light sweeper for count accuracy. This replaces the Redis TTL from ยง7.
Users give up specific seat choice. Fine for a general-admission-style onsale; wrong for resale or accessible seating, where the user must pick the exact seat โ there you fall back to the per-seat Redis lock in ยง7.
For high-demand events, clients poll the seat map every 1โ2s rather than holding an SSE/WebSocket connection.
Use SSE/WebSocket for low-fanout, truly push-driven cases; for a 50k-viewer onsale map, cached polling is cheaper and more robust.
User reserves a seat then vanishes
The Redis lock has a 10-min TTL; it auto-releases and the seat returns to available. No sweeper job needed. Availability count is recomputed on read (or the count is decremented/incremented as locks flip).
Two users grab the last seat at the same instant
SET NX is atomic โ exactly one succeeds and gets the lock. The loser gets 409 immediately. Even if both somehow reached the DB, the commit's FOR UPDATE / status check lets only one flip the row to booked.
Payment succeeds but the booking write fails
Charge Stripe with the idempotency key after (or coordinated with) flipping ticket state, and treat the booking as an idempotent workflow: on retry, the Stripe key prevents a second charge and the DB txn is re-attempted. If it can't complete, refund via the same key. Prefer capturing payment only once seats are durably committed.
Search shows a seat that's already gone
Expected โ search/availability is eventually consistent (CDC + cached counts). The reserve step is the authority: the stale view just means an occasional "sorry, just taken" at booking time, which is acceptable for an availability-first read path.
Multiple tickets in one booking (all-or-nothing)
Acquire locks for all ticketIds in a deterministic (sorted) order to avoid deadlock; if any fails, release the rest and fail the whole booking. Commit all ticket rows in one DB transaction.
Redis (lock store) goes down
Booking is the consistency-first path, so fail closed: the DB transaction with row locking is the real guarantee, so worst case we fall back to DB-only reservation (slower, still correct) or briefly reject bookings. Never fail open into a double-sell.
Why not just lock rows in Postgres and skip Redis?
You can, and the DB is the ultimate guarantee. Redis is there to keep long-held "reserved-but-not-paid" state off the leader and to absorb the popular-event spike cheaply. It's a performance layer in front of the correctness layer.
Read-heavy 100:1. 10M users on one on-sale โ without a queue that's a multi-million/s spike on booking; the waiting queue admits maybe a few thousand/s into the real flow.
A ticket lock is ~tens of bytes; even a 100k-seat stadium is a few MB in Redis, all TTL'd. Elasticsearch index sized to event catalog, not to traffic.
SET NX Redis lock per seat with a 10-minute TTL so abandoned carts self-release; payment commits a single Postgres transaction that flips ticket status and is the real source of truth โ Redis is just a fast reservation in front of it. Every mutation is idempotent via a client UUID key, forwarded to Stripe so retries never double-charge. Postgres is single-leader for writes with read replicas and is sharded by eventId so a megaevent stays on one partition. For a 10M-user on-sale we put a Redis virtual waiting queue in front, admitting a bounded trickle in FIFO order so the booking path never sees the full spike. If Redis dies we fail closed to the DB's row locks โ never into a double-sell.